上一篇我們成功在 Next.js 安裝 TailwindCSS,今天我們要實際來切版首頁,顯示文章列表!
這個系列文章主要在呈現用 Next.js 當作 WordPress 前端會遇到的各種眉眉角角,切出好看的版不是這個系列重點,因此這邊主要 demo TailwindCSS 的範例用法,並且會參照現成的 cms-wordpress example,稍做修改來當作我們部落格模板。
最後呈現的畫面如下面兩張圖,支援手機版和桌面版的 RWD。
桌面版:
手機版:
這邊主要參照 Next.js 官方 cms-wordpress 範例來實作,這個範例也是用 TailwindCSS 作為 CSS framework,並且裡面已經切出了首頁和文章內頁的樣式範例。
我們這一篇會先把首頁相關樣式挑出來,稍做修改來使用。
這篇的完整程式碼改動可以在這個 commit 看到。
而我在自己的 oh-so-pro-blog 範例專案有先做一些影響檔案架構的設計與改動,包含:
import IndexPage from '@/components/templates/IndexPage'
),而非超多層相對路徑(例如:import PostPreview from '../../organisms/PostPreview'
)有機會的話我會在後續文章詳細說明,下面的程式碼我會以我的檔案架構為主,如果你自己的專案檔案架構不一樣,需要根據你的狀況調整檔案放置位置和 import 路徑。
首先是首頁進入點 /src/pages/index.js,精簡了 return 區塊,將全站 Layout 和首頁樣式 IndexPage 獨立成 component,完整 code 如下:
import { useMemo } from 'react'
import { useQuery } from '@apollo/client'
import { initializeApollo, addApolloState } from '@/lib/apolloClient'
import { allPostsQueryVars, ALL_POSTS_QUERY, transformAllPostsData } from '@/graphql/allPostsQuery'
import Layout from '@/components/layout'
import IndexPage from '@/components/templates/IndexPage'
export default function Home() {
const { data } = useQuery(ALL_POSTS_QUERY, {
variables: allPostsQueryVars,
})
const allPosts = useMemo(() => transformAllPostsData(data), [data]) || []
return (
<Layout>
<IndexPage posts={allPosts} />
</Layout>
)
}
export async function getStaticProps() {
const apolloClient = initializeApollo()
await apolloClient.query({
query: ALL_POSTS_QUERY,
variables: allPostsQueryVars,
})
return addApolloState(apolloClient, {
props: {},
revalidate: 1,
})
}
接著先看 /src/components/layout.js,這將來會是全站各頁面共通的 layout,完整 code 如下:
import Head from 'next/head'
import Footer from '@/components/organisms/footer'
import Meta from '@/components/meta'
export default function Layout({ children }) {
return (
<>
<Head>
<title>Oh. So. Pro. blog</title>
</Head>
<div className="min-h-screen">
<main>{children}</main>
</div>
<Footer />
</>
)
}
layout 裡面的 Footer 則在 /src/components/organisms/Footer.js:
import Container from '@/components/molecules/Container'
export default function Footer() {
return (
<footer>
<Container>
<div className="flex flex-col lg:flex-row items-center py-28">
<h3 className="lg:pr-4 mb-10 lg:mb-0 lg:w-1/2 text-4xl lg:text-5xl font-bold tracking-tighter leading-tight text-center lg:text-left">
A pro blog for productive professional programmers
</h3>
</div>
</Container>
</footer>
)
}
Footer 用到的 Container 則在 /src/components/molecules/Container.js:
export default function Container({ children }) {
return <div className="px-5 mx-auto w-full max-w-7xl">{children}</div>
}
共用 Layout 到此全部實作完畢,接著實際進到首頁的內容,/src/components/templates/IndexPage.js:
import Container from '@/components/molecules/Container'
import Intro from '@/components/molecules/Intro'
import HeroPost from '@/components/organisms/HeroPost'
import PostList from '@/components/organisms/PostList'
export default function IndexPage({ posts }) {
const heroPost = posts?.[0]
const morePosts = posts?.slice(1) || []
return (
<Container>
<Intro />
{heroPost && (
<HeroPost
title={heroPost.title}
featuredImage={heroPost.featuredImage}
date={heroPost.date}
uri={heroPost.uri}
excerpt={heroPost.excerpt}
/>
)}
{morePosts.length > 0 && <PostList posts={morePosts} />}
</Container>
)
}
/src/components/molecules/Intro.js:
export default function Intro() {
return (
<section className="flex flex-col md:flex-row md:justify-between items-center mt-16 mb-16 md:mb-12">
<h1 className="md:pr-8 text-6xl md:text-8xl font-bold tracking-tighter leading-tight">
Oh. So. Pro.
</h1>
<h4 className="md:pl-8 mt-5 text-lg text-center md:text-left">A pro blog for productive professional programmers</h4>
</section>
)
}
/src/components/organisms/HeroPost.js:
import Link from 'next/link'
import CoverImage from '@/components/atoms/CoverImage/CoverImage'
import Date from '@/components/atoms/Date/Date'
export default function HeroPost({ title, featuredImage, date, excerpt, uri }) {
return (
<section>
<div className="mb-8 md:mb-16">
{featuredImage && <CoverImage title={title} featuredImage={featuredImage} uri={uri} />}
</div>
<div className="md:grid md:grid-cols-2 mb-20 md:mb-28 gap-4">
<div>
<h3 className="mb-4 text-4xl lg:text-6xl leading-tight line-clamp-3">
<Link href={uri}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="mb-4 md:mb-0 text-lg">
<Date dateString={date} />
</div>
</div>
<div>
<p className="mb-4 text-lg leading-relaxed line-clamp-6">{excerpt}</p>
</div>
</div>
</section>
)
}
/src/components/atoms/CoverImage.js:
import Image from 'next/image'
import Link from 'next/link'
export default function CoverImage({ featuredImage, uri }) {
if (!uri || !featuredImage?.sourceUrl) return null
return (
<div className="sm:mx-0 w-full aspect-w-16 aspect-h-9">
<Link href={uri}>
<a>
<Image
layout="fill"
objectFit="cover"
alt={featuredImage?.altText}
src={featuredImage?.sourceUrl}
className="shadow hover:shadow-lg transition-shadow duration-200"
/>
</a>
</Link>
</div>
)
}
/src/components/atoms/Date.js:
import { parseISO, format } from 'date-fns'
export default function Date({ dateString }) {
if (!dateString) return null
const date = parseISO(dateString)
return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>
}
/src/components/organisms/PostList.js:
import PostPreview from '@/components/organisms/PostPreview'
export default function PostList({ posts }) {
return (
<section>
<h2 className="mb-8 text-6xl md:text-7xl font-bold tracking-tighter leading-tight">
More Stories
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-32">
{posts.map((post) => (
<PostPreview
key={post?.id}
title={post?.title}
featuredImage={post?.featuredImage}
date={post?.date}
uri={post?.uri}
excerpt={post?.excerpt}
/>
))}
</div>
</section>
)
}
/src/components/organisms/PostPreview.js:
import Link from 'next/link'
import CoverImage from '@/components/atoms/CoverImage'
import Date from '@/components/atoms/Date'
export default function PostPreview({ title, featuredImage, date, excerpt, uri }) {
return (
<div>
<div className="mb-5">
{featuredImage && <CoverImage title={title} featuredImage={featuredImage} uri={uri} />}
</div>
<h3 className="mb-3 text-3xl leading-snug line-clamp-3">
<Link href={uri}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="mb-4 text-lg">
<Date dateString={date} />
</div>
<p className="mb-4 text-lg leading-relaxed line-clamp-5">{excerpt}</p>
</div>
)
}
TailwindCSS 也是有 plugin 的,可以加入更多 class 支援更複雜的 CSS 效果。
因為文章區塊我希望文章 title 和 excerpt 文字最多只顯示三行和五行,超過行數的話要用 ...
截斷,這很適合用 line-clamp css 技巧來實現,但 TailwindCSS 官方沒有內建對應 class 能用,而是做成 plugin 的方式,需要時再額外安裝,因此我們這邊來把它安裝起來。
@tailwindcss/line-clamp 相關連結:
安裝首先輸入下面指令:
yarn add @tailwindcss/line-clamp
接著修改 /tailwind.config.js,在 plugins 陣列多加這行:
module.exports = {
mode: 'jit',
purge: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {},
variants: {
extend: {},
},
plugins: [
require('@tailwindcss/line-clamp'), // <=== Add this
],
}
安裝完就會多出 line-clamp-3
、line-clamp-5
等等的 class 可以直接套用了,非常方便!
在實作 CoverImage 時,我也用到了 TailwindCSS 的 aspect-ratio plugin,來指定圖片的長寬比例,因此我們也要安裝它:
yarn add @tailwindcss/aspect-ratio
然後一樣修改 /tailwind.config.js,在 plugins 陣列多加一行:
module.exports = {
// ...
plugins: [
require('@tailwindcss/line-clamp'),
require('@tailwindcss/aspect-ratio'), // <=== Add this
],
}
安裝完後一樣會多 class 可以用,像是 aspect-w-16 aspect-h-9
可以指定長寬比為 16:9。
aspect-ratio plugin 相關連結:
最後再次執行 yarn dev
,應該就會看到首頁變得比較漂亮了!恭喜你!
這篇的完整程式碼改動可以在這個 commit 看到。
今天我們成功使用 TailwindCSS 完成首頁文章列表的切版了,下一篇我們會繼續切版文章內頁!